package org.bookshare.document.daisy;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;

import org.benetech.beans.Filter;
import org.benetech.beans.FilterCallback;
import org.benetech.beans.NameOrIdMatchesFilter;
import org.benetech.collections.IdTree;
import org.benetech.collections.ListMap;
import org.benetech.collections.Tree;
import org.benetech.collections.TreeFilter;
import org.benetech.collections.Tree.ListDirection;
import org.benetech.event.EventListener;
import org.benetech.ref.XML;
import org.benetech.ref.XML.Regex;
import org.benetech.util.FileUtils;
import org.benetech.util.StringUtils;
import org.benetech.util.XMLParseUtils;
import org.benetech.util.XMLTreeUtils;
import org.bookshare.document.navigation.NavigationElement;
import org.bookshare.document.navigation.NavigationElementFactory;
import org.bookshare.document.navigation.NavigationElement.NavigationElementType;
import org.bookshare.document.navigation.chapters.ChapterFinder;
import org.bookshare.document.navigation.chapters.Chapters;
import org.jdom.Attribute;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.Namespace;

/**
 * Represents an XML part of a book. Provides primary and secondary navigation. Uses DAISY "level" and "pagenum"
 * elements if present. Also contains advanced structure parsing for more general navigation.
 * @author Reuben Firmin
 */
public final class DTBook {

	public static final String DTBOOK_NAMESPACE = "http://www.daisy.org/z3986/2005/dtbook/";
	private static double ALLOWABLE_PAGE_DEVIATION_PERCENTAGE = 1.10;

	private List<NavigationElement> primaryNavigation;
	private NavigationElementType primaryNavigationType;
	// map of navigation element to treelinker, which is a list of secondary nodes with links back into the docTree
	private ListMap<NavigationElementType, NavigationElement> secondaryNavigation
		= new ListMap<NavigationElementType, NavigationElement>();
	private File xmlFile;
	private Document xmlDoc;

	private int numberFrontPages;
	private int numberNormalPages;
	private int numberSpecialPages;
	private int maxNormalPage;
	private int levelDepth;

	// modifies levelDepth
	private final FilterCallback<Matcher> levelCallback = new FilterCallback<Matcher>() {
		public void callback(final Matcher matcher) {
			final String num = matcher.group(2);
			if (num != null) {
				final int depth = Integer.parseInt(num);
				if (depth > levelDepth) {
					levelDepth = depth;
				}
			}
		}
	};

	// XXX this is somewhat fragile, as it relies on LEVEL_PATTERN not being altered
	private final Filter<Element, Matcher> levelFilter = new NameOrIdMatchesFilter(true,
			levelCallback, XML.Regex.LEVEL_PATTERN);

// --------- Start of class methods

	/**
	 * default Constructor for Testing
	 */
	protected DTBook(){
	}

	/**
	 * Constructor. Does heavy parsing, and possible modification, of the document. Saves to the supplied file.
	 * @param book the document representing the book; elements must be in all lower case
	 * @param xmlFile the file to save the modified document to
	 * @param listener the listener for user feedback
	 * @param addNote true to add a note as the first navigatable item, explaining the navigation choices
	 * @throws IOException if the file couldn't be saved after the document was modified
	 */
	public DTBook(final Document book, final File xmlFile, final EventListener listener, final boolean addNote)
		throws IOException
	{
		listener.message("--------------Parsing DTBook XML to find navigational elements------------");
		final ListMap<String, Element> elements = XMLParseUtils.getDescendentsAsMap(book.getRootElement());
		// get the most specific tree, and apply ids across it
		final IdTree<Element, String, String> bodyMatter =
			new IdTree(XMLTreeUtils.getElementAsTree(elements.getFirst("bodymatter"), null));
		final IdTree<Element, String, String> frontMatter =
			new IdTree(XMLTreeUtils.getElementAsTree(elements.getFirst("frontmatter"), null));

		// attempt to get chapters
		final List<IdTree<Element, String, String>> pages = getPages(bodyMatter, listener);
		final int totalPages = pages == null ? 0 : pages.size();
		final List<NavigationElement> chapters =
			NavigationElementFactory.getChapters(frontMatter, bodyMatter, totalPages, listener);
		// attempt to get pages
		NavigationElementType primaryMethod;
		// build the primary and secondary navigation trees, depending on whether we found chapters
		if (chapters != null) {
			//XXX
			// make sure the chapters are all Id'd
			primaryNavigation = chapters;
			primaryMethod = NavigationElementType.CHAPTER;
		} else {
			final List<IdTree<Element, String, String>> levels =
				(List<IdTree<Element, String, String>>) bodyMatter.toList(new TreeFilter(levelFilter));
			if (levels.size() > 1) {
				primaryNavigation = NavigationElementFactory.convert(
						levels, NavigationElementType.LEVEL, bodyMatter, listener);
				primaryMethod = NavigationElementType.LEVEL;
			} else {
				final List<IdTree<Element, String, String>> headings =
					(List<IdTree<Element, String, String>>)
					bodyMatter.toList(new TreeFilter(new NameOrIdMatchesFilter(true, XML.Regex.HEADINGS_PATTERN)));
				primaryNavigation = NavigationElementFactory.convert(
						headings, NavigationElementType.HEADING, bodyMatter, listener);
				primaryMethod = NavigationElementType.HEADING;
			}
 		}
		this.primaryNavigationType = primaryMethod;
		// derive secondary navigation from the primary
		switch (primaryMethod) {
			case CHAPTER:
				addToSecondaryNavigation(NavigationElementType.LEVEL, bodyMatter, levelFilter, listener);
				// fall thru
			case LEVEL:
				addToSecondaryNavigation(NavigationElementType.HEADING, bodyMatter, XML.Find.HEADING_FILTER, listener);
				// fall thru
			case HEADING:
				addToSecondaryNavigation(NavigationElementType.PARAGRAPH, bodyMatter, XML.Find.PARAGRAPH_FILTER, listener);
				if (pages != null) {
					addToSecondaryNavigation(NavigationElementType.PAGE, pages, bodyMatter, listener);
				}
			default:
				break;
		}

		// add the navigation note if requested
		if (addNote) {
			addNavigationNoteToDocument(frontMatter, primaryMethod, pages != null, listener);
		}

		FileUtils.saveDocumentToFile(book, xmlFile, true);
		this.xmlDoc = book;
		this.xmlFile = xmlFile;
		listener.message("--------------Done Parsing DTBook XML------------");
	}

	/**
	 * Add a note explaining the navigation strategy to the document. Primary navigation must have been determined.
	 * @param primary What type of primary navigation
	 * @param pageNavigation Whether there is page navigation in secondary
	 */
	private void addNavigationNoteToDocument(final IdTree<Element, String, String> frontMatter,
			final NavigationElementType primary, final boolean pageNavigation, final EventListener listener)
	{
		final Namespace daisy = Namespace.getNamespace(DTBOOK_NAMESPACE);
		final Element h1 = new Element("h1", daisy);
		h1.addContent("Bookshare Converted Book");
		final Element p1 = new Element("p", daisy);
		p1.addContent("NOTE - This book has been donated by the publisher.  "
				+ "It has been automatically converted to DAISY from another format by Bookshare software.  "
				+ "Navigation elements have been chosen based on patterns found by the software. ");
		final Element p2 = new Element("p", daisy);
		final StringBuffer primaryNote = new StringBuffer("The primary navigation in this book is based on ");
		switch (primary) {
			case CHAPTER :
				primaryNote.append("chapters.");
				break;
			case HEADING :
				primaryNote.append("section headings.");
				break;
			case LEVEL :
				primaryNote.append("document levels.");
				break;
			default :
				throw new UnsupportedOperationException("Update this to handle :" + primary);
		}
		p2.addContent(primaryNote.toString());
		final StringBuffer secondaryNote = new StringBuffer("The secondary navigation in this book is based on ");
		if (pageNavigation) {
			secondaryNote.append("pages, ");
		}

		final NavigationElementType[] navigationTypes = secondaryNavigation.keySet().toArray(new NavigationElementType[0]);
		for (int i = 0; i < navigationTypes.length; i++) {
			if (i > 0) {
				if (i + 1 < navigationTypes.length) {
					secondaryNote.append(", ");
				} else {
					secondaryNote.append(" and ");
				}
			}
			switch (navigationTypes[i]) {
				case LEVEL :
					secondaryNote.append("levels");
					break;
				case HEADING :
					secondaryNote.append("headings");
					break;
				case PARAGRAPH :
					secondaryNote.append("paragraphs");
					break;
				case PAGE:
					secondaryNote.append("pages");
					break;
				default :
					throw new UnsupportedOperationException("Update this to handle :" + navigationTypes[i]);
			}
		}
		secondaryNote.append(". Note that not all players support secondary navigation.");
		final Element p3 = new Element("p", daisy);
		p3.addContent(secondaryNote.toString());
		final Element noteElement = new Element("level1", daisy);
		noteElement.addContent(p1);
		noteElement.addContent(p2);
		noteElement.addContent(p3);
		if (!pageNavigation) {
			final Element noPages = new Element("p", daisy);
			noPages.addContent("Note that no page numbers were detected in this document, and that therefore page " +
				"based navigation is not available.");
			noteElement.addContent(noPages);
		}
		noteElement.getAttributes().add(new Attribute("id", "bookshare_note"));

		// XXX this might break; what's teh fallback?
		final Element navParent = frontMatter.getContent();
		// this is a little bit kludgey, we have to modify both data structures at once
		navParent.addContent(0, noteElement);
		frontMatter.insertChild(new IdTree<Element, String, String>(new Tree<Element, String, String>(noteElement)));
		final Tree<Element, Object, Object> wrapper = new Tree<Element, Object, Object>(noteElement);
		primaryNavigation.add(0, NavigationElementFactory.convert(new IdTree<Element, String, String>(wrapper),
				primary, frontMatter, listener));
	}

	/**
	 * Add another set of elements to the secondary navigation map.
	 * @param navType The description of these elements
	 * @param book The book as a tree
	 * @param filter The filter to apply to the contents of the tree
	 */
	private void addToSecondaryNavigation(final NavigationElementType navType, final IdTree<Element, String, String> bodyMatter,
			final Filter<Element, ? > filter, EventListener listener)
	{
		addToSecondaryNavigation(navType, (List<IdTree<Element, String, String>>) bodyMatter.toList(new TreeFilter(filter)),
				bodyMatter, listener);
	}

	/**
	 * Add another set of elements to the secondary navigation map.
	 * @param navType The description of these elements
	 * @param book The book as a tree
	 * @param nodes The nodes to add to the tree
	 */
	private void addToSecondaryNavigation(final NavigationElementType navType,
			final List<IdTree<Element, String, String>> nodes, final IdTree<Element, String, String> bodyMatter,
			EventListener listener)
	{
		secondaryNavigation.put(navType, NavigationElementFactory.convert(nodes, navType, bodyMatter, listener));
	}

	/**
	 * Return the primary navigation, i.e. pointers into the major structure of the book.
	 * @return Never null
	 */
	public List<NavigationElement> getPrimaryNavigation() {
		return primaryNavigation;
	}

	public NavigationElementType getPrimaryNavigationType() {
		return primaryNavigationType;
	}

	/**
	 * Return the secondary navigation, i.e. the minor (more detailed) structure of the book. This is a listmap of
	 * navigationelement against a list of IdTrees; the IdTrees are specific nodes of the type of navigation. The
	 * children or parents of the IdTrees are only relevant if you need to figure out their context in the document.
	 * @return Never null
	 */
	public ListMap<NavigationElementType, NavigationElement> getSecondaryNavigation() {
		return secondaryNavigation;
	}

	/**
	 * The file backing the book.
	 * @return Never null
	 */
	public File getXMLFile() {
		return xmlFile;
	}

	/**
	 * The Document backing the book.
	 * @return Never null
	 */
	public Document getXMLDocument() {
		return xmlDoc;
	}

	/**
	 * Get the elements in this XML containing page numbers. There are two different stratagies for solving this.
	 * First, look to see if there are page-number elements in the document.
	 * Secondly, the pagenums can be defined as the longest sequence of sequential
	 * numbers in seperate elements in the overall document. The element names are not altered by this method.
	 * @param documentTree The document to parse
	 * @param listener The event listener
	 * @return Null if none could be found
	 */
	public List<IdTree<Element, String, String>> getPages(final IdTree<Element, String, String> documentTree,
			final EventListener listener)
	{
		final List<IdTree<Element, String, String>> elements =
			(List<IdTree<Element, String, String>>) documentTree.toList(ListDirection.DEPTH);
		final List<Element> elemList = XMLParseUtils.flattenToList(documentTree.getContent());
		final List<Integer> matchList = XMLParseUtils.getMatchingIndices(elemList, XML.Find.PAGENUM_FILTER);
		if (matchList.size() != 0) {
			List<IdTree<Element, String, String>> group = new ArrayList<IdTree<Element, String, String>>();
			for (Integer i : matchList) {
				group.add((IdTree<Element, String, String>) elements.get(i));
			}
			return group;
		}
		final ListMap<String, IdTree<Element, String, String>> numericalElements =
			getNumberedElementSets((List<IdTree<Element, String, String>>) documentTree.toList(ListDirection.DEPTH));
		List<IdTree<Element, String, String>> bestGroup = null;
		for (Map.Entry<String, List<IdTree<Element, String, String>>> candidateGroup : numericalElements.entrySet()) {
			//minimum size has to be greater than one
			if (candidateGroup.getValue().size() > 1) {
				final String key = candidateGroup.getKey();
				long lastNum = -1;
				boolean found = true;
				for (final IdTree<Element, String, String> candidate : candidateGroup.getValue()) {
					final long num = Long.parseLong((String) candidate.getMetadataContent(key).get(0).getValue());
					if (lastNum >= 0 && num != lastNum + 1) {
						found = false;
						break;
					}
					lastNum = num;
				}
				if (found && lastNum <= candidateGroup.getValue().size() * ALLOWABLE_PAGE_DEVIATION_PERCENTAGE) {
					// TODO we determine only by size...this could be more sophisticated
					if (bestGroup == null || candidateGroup.getValue().size() > bestGroup.size()) {
						bestGroup = candidateGroup.getValue();
						// assign metadata
						this.maxNormalPage = Integer.parseInt(
								(String) bestGroup.get(bestGroup.size() - 1).getMetadataContent(key).get(0).getValue());
						this.numberNormalPages = maxNormalPage;
					}
				}
			}
		}
		return bestGroup;
	}

	/**
     * Get sets of numbered elements, mapped against the concatenated text that surrounds the number. E.g. A["page 1."],
     * B["page 2."] would be mapped as "page." -> A, B. The number will be inserted as tree metadata, bound to
     * the key of the set's mapping.
     * @param rootElement the element
     * @return never null
     */
    private static ListMap<String, IdTree<Element, String, String>> getNumberedElementSets(
    		List<IdTree<Element, String, String>> elements)
	{
    	final ListMap<String, IdTree<Element, String, String>> elementSets = new ListMap<String,
    		IdTree<Element, String, String>>();
    	for (IdTree<Element, String, String> element : elements) {
    		final Matcher matcher = Regex.NUMBER_PATTERN.matcher(element.getContent().getTextNormalize());
    		if (matcher.matches()) {
    			final String prev = matcher.group(1);
    			final String num = matcher.group(2);
    			final String post = matcher.group(3);
    			final StringBuffer name = new StringBuffer(element.getContent().getName()).append(".");
    			for (Attribute attribute : (List<Attribute>) element.getContent().getAttributes()) {
    				if (!attribute.getName().equalsIgnoreCase("id")) {
    					name.append(attribute.getName()).append(".").append(attribute.getValue()).append(".");
    				}
    			}
    			name.append(prev);
    			name.append(post);
    			elementSets.put(name.toString(), element);
    			element.addMetaContent(name.toString(), num);
    		}
    	}
    	return elementSets;
    }


	// TODO javadoc
	public final int getLevelDepth() {
    	return levelDepth;
    }

	// TODO javadoc
	public final int getMaxNormalPage() {
    	return maxNormalPage;
    }

	// TODO javadoc
	public final int getNumberFrontPages() {
		// always 0 for now
    	return numberFrontPages;
    }

	// TODO javadoc
	public final int getNumberNormalPages() {
    	return numberNormalPages;
    }

	// TODO javadoc
	public final int getNumberSpecialPages() {
		// always 0 for now
    	return numberSpecialPages;
    }
}
